--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 112dc16ec9888dc44c43ddd4d6f9ce1ab2da197f
Parents : 38a0976
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-19T06:15:05-05:00
feat: improve message and relay chat functionality with unread badge dismissal and new API endpoint for clearing active rooms
Changes
8 files changed, 140 insertions(+), 6 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8eeb4410..ada7a46d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -61,6 +61,7 @@ All notable changes to this project will be documented in this file.
- Notification bell removed from the header
- Unread badge stays circular and remains visible when the sidebar is collapsed
- Open conversations mark as read when a new message arrives without needing to reselect the thread
+- Unread badges dismiss when navigating back to an already-open Messages or Relay Chat room
- Startup stage logs no longer print the same stage twice
- Ctrl+C shutdown no longer floods reentrant logging errors from RNS.exit containment
- RN Status interface mode labels match Reticulum modes again, including Internal
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 56d027e5..eb613e2a 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 8f3c49cf..63d526ec 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -10006,6 +10006,10 @@ class ReticulumMeshChat:
except ValueError as e:
return web.json_response({"message": str(e)}, status=400)
manager.set_active(hub, room)
+ self._mark_rrc_mention_notifications_viewed(
+ request.match_info.get("hub_hash", ""),
+ room,
+ )
return web.json_response(
{"messages": messages, "members": members, "has_more": has_more},
)
@@ -10048,6 +10052,17 @@ class ReticulumMeshChat:
)
return web.json_response({"message": "Marked read"})
+ @routes.post("/api/v1/rrc/active/clear")
+ async def rrc_clear_active(request):
+ manager = self.rrc_manager
+ if manager is None:
+ return web.json_response(
+ {"message": "Relay chat is not available"},
+ status=503,
+ )
+ manager.set_active(None, None)
+ return web.json_response({"message": "Active room cleared"})
+
@routes.post("/api/v1/rrc/hubs/{hub_hash}/command")
async def rrc_hub_command(request):
_, hub, error = _rrc_require_hub(request.match_info.get("hub_hash", ""))
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index c47ba726..0dbd051a 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -523,6 +523,8 @@ export default {
const currentHash = this.selectedPeer?.destination_hash;
const normalizedNew = Utils.normalizeMeshchatHashHex(newHash);
if (currentHash && Utils.normalizeMeshchatHashHex(currentHash) === normalizedNew) {
+ // Still dismiss unread / nav badge when the chat is already open.
+ this.dismissUnreadForOpenDestination(normalizedNew);
return;
}
this.onComposeNewMessage(newHash);
@@ -1243,6 +1245,38 @@ export default {
const viewer = this.paneViewers?.[this.focusedPaneId];
viewer?.markConversationAsRead?.(conversation);
},
+ dismissUnreadForOpenDestination(destinationHash) {
+ const normalized = Utils.normalizeMeshchatHashHex(destinationHash || "");
+ if (!normalized) {
+ return;
+ }
+ const conversation =
+ this.conversations.find((c) => Utils.normalizeMeshchatHashHex(c.destination_hash) === normalized) ||
+ this.selectedPeer;
+ if (!conversation) {
+ return;
+ }
+ const viewer = this.paneViewers?.[this.focusedPaneId];
+ if (viewer?.markConversationAsRead) {
+ viewer.markConversationAsRead(conversation, { force: true });
+ return;
+ }
+ // Viewer not mounted yet (restored panes). Optimistically clear local unread.
+ if (conversation.is_unread) {
+ conversation.is_unread = false;
+ }
+ Promise.resolve(
+ window.api.post(`/api/v1/lxmf/conversations/${normalized}/mark-as-read`)
+ )
+ .then(() => {
+ GlobalEmitter.emit("notifications-changed");
+ NotificationUtils.clearMessageNotifications(normalized);
+ if (GlobalState.unreadConversationsCount > 0) {
+ GlobalState.unreadConversationsCount -= 1;
+ }
+ })
+ .catch(() => {});
+ },
onCloseConversationViewer: function () {
// clear selected peer
this.selectedPeer = null;
diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index e161be62..e0b9d6b3 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -1662,6 +1662,9 @@ export default {
if (this.smMq) {
this.smMq.removeEventListener("change", this.onSmMqChange);
}
+ // Leaving the page must stop treating the last room as "viewed" so new
+ // mentions while away can bump unread again.
+ window.api.post("/api/v1/rrc/active/clear").catch(() => {});
},
methods: {
selectView(view) {
@@ -2487,9 +2490,9 @@ export default {
this.members = response.data?.members || [];
this.hasMorePrevious = Boolean(response.data?.has_more);
this.scrollToBottom();
- if (!options.restore) {
- await this.fetchHubs();
- }
+ // Always dismiss unread for the opened room (including layout restore /
+ // page navigation when the room was already selected).
+ await this.markRoomRead(hubHash, room, { refreshHubs: true });
this.persistRelayLayout();
} catch {
if (seq !== this.roomSelectSequence) {
@@ -2498,6 +2501,48 @@ export default {
this.hasMorePrevious = false;
}
},
+ clearLocalRoomUnread(hubHash, room) {
+ const hub = this.hubs.find((h) => h.hub_hash === hubHash);
+ if (!hub) {
+ return;
+ }
+ if (Array.isArray(hub.mention_rooms)) {
+ hub.mention_rooms = hub.mention_rooms.filter((r) => r !== room);
+ }
+ if (Array.isArray(hub.unread_rooms)) {
+ hub.unread_rooms = hub.unread_rooms.filter((r) => r !== room);
+ }
+ if (hub.unread_counts && typeof hub.unread_counts === "object") {
+ const next = { ...hub.unread_counts };
+ delete next[room];
+ hub.unread_counts = next;
+ }
+ if (typeof hub.total_unread === "number") {
+ hub.total_unread = Object.values(hub.unread_counts || {}).reduce(
+ (sum, n) => sum + (Number(n) || 0),
+ 0
+ );
+ }
+ this.updateUnreadBadge();
+ },
+ async markRoomRead(hubHash, room, { refreshHubs = false } = {}) {
+ if (!hubHash || !room) {
+ return;
+ }
+ this.clearLocalRoomUnread(hubHash, room);
+ try {
+ await window.api.post(`/api/v1/rrc/hubs/${hubHash}/rooms/${this.encodeRoom(room)}/read`);
+ } catch {
+ // GET messages already marks active/read on the server; ignore duplicate failures.
+ }
+ if (refreshHubs) {
+ await this.fetchHubs();
+ // Keep dismiss sticky if a concurrent hubs fetch still had the old unread.
+ this.clearLocalRoomUnread(hubHash, room);
+ } else {
+ this.updateUnreadBadge();
+ }
+ },
async loadPreviousMessages() {
if (this.isLoadingPrevious || !this.hasMorePrevious || !this.selectedHubHash || !this.selectedRoom) {
return;
@@ -3085,13 +3130,18 @@ export default {
if (json.message.kind === "system" || json.message.kind === "notice") {
this.refreshMembers();
}
+ // Chat is already open: dismiss unread/mention badge without waiting
+ // for a later hub list refresh race.
+ this.markRoomRead(json.hub_hash, json.room);
} else if (json.message && json.message.kind === "msg") {
const onRelayPage = this.$route?.name === "relay-chat" || this.$route?.name === "relay-chat-popout";
if (!onRelayPage || json.hub_hash !== this.selectedHubHash || json.room !== this.selectedRoom) {
ToastUtils.info(this.$t("relay_chat.new_message_toast", { room: json.room || "" }));
}
+ this.fetchHubs();
+ } else {
+ this.fetchHubs();
}
- this.fetchHubs();
} else if (json.type === "rrc.server.change") {
this.fetchServers();
} else if (json.type === "announce" && json.announce && json.announce.aspect === "rrc.hub") {
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 8b084134..be4cd6cb 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -1160,6 +1160,10 @@
"method": "POST",
"path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/active/clear"
+ },
{
"method": "GET",
"path": "/api/v1/rrc/servers"
diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index b9b95c73..10b6f118 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -28,7 +28,7 @@ describe("MessagesPage.vue", () => {
localStorage.clear();
axiosMock = {
get: vi.fn(),
- post: vi.fn(),
+ post: vi.fn().mockResolvedValue({ data: {} }),
};
window.api = axiosMock;
@@ -618,16 +618,21 @@ describe("MessagesPage.vue", () => {
await wrapper.vm.$nextTick();
// simulate a conversation already opened in the focused pane (as onPeerClick does)
- wrapper.vm.selectedPeer = { destination_hash: destHash, display_name: "Peer" };
+ wrapper.vm.selectedPeer = { destination_hash: destHash, display_name: "Peer", is_unread: true };
+ wrapper.vm.conversations = [
+ { destination_hash: destHash, display_name: "Peer", is_unread: true },
+ ];
await wrapper.vm.$nextTick();
const composeSpy = vi.spyOn(wrapper.vm, "onComposeNewMessage");
+ const dismissSpy = vi.spyOn(wrapper.vm, "dismissUnreadForOpenDestination");
// simulate router.replace propagating the same hash back into the prop
await wrapper.setProps({ destinationHash: destHash });
await wrapper.vm.$nextTick();
expect(composeSpy).not.toHaveBeenCalled();
+ expect(dismissSpy).toHaveBeenCalledWith(destHash);
});
it("composes the conversation when the route hash differs from the selected peer", async () => {
diff --git a/tests/frontend/RelayChatPage.test.js b/tests/frontend/RelayChatPage.test.js
index 8d2adcd9..c8d3f62d 100644
--- a/tests/frontend/RelayChatPage.test.js
+++ b/tests/frontend/RelayChatPage.test.js
@@ -189,6 +189,31 @@ describe("RelayChatPage.vue", () => {
expect(wrapper.vm.messages[0].text).toBe("hello");
expect(wrapper.vm.members.length).toBe(1);
expect(wrapper.text()).toContain("hello");
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ `/api/v1/rrc/hubs/${HUB_HASH}/rooms/lobby/read`
+ );
+ });
+
+ it("clears local mention unread when marking an open room read", async () => {
+ const wrapper = mountPage();
+ await vi.waitFor(() => expect(wrapper.vm.hubs.length).toBe(1));
+
+ wrapper.vm.hubs = [
+ makeHub({
+ mention_rooms: ["lobby"],
+ unread_rooms: ["lobby"],
+ unread_counts: { lobby: 2 },
+ total_unread: 2,
+ }),
+ ];
+ wrapper.vm.updateUnreadBadge();
+ expect(wrapper.vm.hubs[0].mention_rooms).toEqual(["lobby"]);
+
+ await wrapper.vm.markRoomRead(HUB_HASH, "lobby", { refreshHubs: false });
+
+ expect(wrapper.vm.hubs[0].mention_rooms).toEqual([]);
+ expect(wrapper.vm.hubs[0].unread_rooms).toEqual([]);
+ expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/rrc/hubs/${HUB_HASH}/rooms/lobby/read`);
});
it("keeps websocket messages that arrive while selectRoom is loading", async () => {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────